Skip to content

Parse each Java TurboModule method's JNI signature once - #59414

Merged
meta-codesync[bot] merged 1 commit into
react:mainfrom
hryhoriiK97:android-turbomodule-signature-cache
Oct 29, 2026
Merged

meta-codesync[bot] merged 1 commit into
react:mainfrom
hryhoriiK97:android-turbomodule-signature-cache

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

This is the Android counterpart of #59394.

On Android, every call from JS to a Java TurboModule method goes through JavaTurboModule::invokeJavaMethod (ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp). On every call it parsed the method's JNI signature into a new std::vector<std::string> of argument types (getMethodArgTypesFromSignature), and for methods that return a number or a boolean it cut the return type out of the signature again with substr. Long type names such as Lcom/facebook/react/bridge/ReadableMap; don't fit in the small-string buffer, so each of them was a heap allocation on every call.

A method's signature doesn't change, so this change parses it on the method's first call and keeps the result. Each JavaTurboModule now has one MethodInfo per method (the argument types and the return type), keyed by the method's jmethodID, which is already resolved at that point. A mutex guards the map. Entries are never removed while the module is alive, and std::unordered_map doesn't move its elements, so the returned reference stays valid for the rest of the call. The members are private, so there is no API change.

Memory: one entry per method that is actually called, about 150–300 bytes each (the type strings and the map node). The same strings were already allocated on every call and freed right after.

One side effect: JavaTurboModule now holds a std::mutex, so it can't be copied or moved. Copying a module was already deprecated (it has a user-declared destructor) and nothing in the repository copies one; generated modules are created with std::make_shared.

Changelog:

[ANDROID] [CHANGED] - Parse each TurboModule method's JNI signature once instead of on every call

Test Plan:

  • clang-format --dry-run --Werror (the repository's pinned version) on the changed files passes.
  • RNTester built with Gradle in Release (assembleRelease, arm64-v8a) and run on a physical Samsung Galaxy A14 (Android 15). In the TurboModule example, "Run function call tests" returns the same values for all 18 methods with and without this change, including callbacks, a resolved and a rejected promise, and ArrayBuffers.
  • To run SampleTurboModule in a Release build I had to change two things locally, in the sample module only and in both builds:
    • In Release, creating it fails with A launcher is already registered for key 'com.facebook.fbreact.specs.SampleTurboModule:e2'. The launcher key is built from the contract class names, and R8 seems to merge the contract classes, so the three launchers get the same key. I passed an explicit key to each registerForActivityResult call.
    • It shows a Toast on every call, so I turned that off for the measurement.
  • Measurement. RNTester Playground calling NativeSampleTurboModule methods in a loop (20,000 calls each for the sync methods, 5,000 for the callback), timed with performance.now(). Two Release APKs, before and after this change, installed in the order before, after, after, before, 4 launches each after a warm-up launch. The presses were driven by Argent on the physical phone. Mean time per call:
Call before this PR change
getString 1.93 µs 1.66 µs −14%
getValue (number, string, object) 18.96 µs 17.62 µs −7%
getValueWithCallback 11.46 µs 10.66 µs −7%
getArray (3 items) 15.84 µs 14.83 µs −6%
getObject (small) 14.02 µs 13.19 µs −6%

For these rows, the ranges of the two arms don't overlap. getNumber (1.28 → 1.14 µs), getBool, the 20-key getObject, voidFunc and promises were within noise in this run. An earlier run of the same change behind a switch in one APK gave −5% to −20% for these calls and getNumber (−20%).

🤖 Generated with Claude Code

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 23, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 23, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Visual regression

No visual differences at 41b5c52b5a20.

ios: no capture

Comment on lines +68 to +75
// A method's JNI signature, parsed on its first call since it doesn't change.
struct MethodInfo {
std::vector<std::string> argTypes;
std::string returnType;
};
std::unordered_map<jmethodID, MethodInfo> methodInfos_;
std::mutex methodInfosMutex_;
const MethodInfo &getMethodInfo(jmethodID methodID, const std::string &methodSignature);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a follow-up PR, let's actually store jmethodID here too, and index based on method name. I previously added it as a mutable method signature to act as a similar "cache", but it's actually simpler to have a single cache in the TurboModule instance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@javache, thank you for the comment! I'll create a follow-up PR. 👍

@meta-codesync

meta-codesync Bot commented Oct 28, 2026

Copy link
Copy Markdown

@javache has imported this pull request. If you are a Meta employee, you can view this in D128040903.

@javache

javache commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Please rebase on 3cee68f

JavaTurboModule::invokeJavaMethod parsed the method's JNI signature into a
vector of argument type strings on every call, and cut the return type
out of it again for number and boolean methods. Keep both in a MethodInfo
per method, keyed by its jmethodID, like ObjCTurboModule does on iOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 force-pushed the android-turbomodule-signature-cache branch from 8d39dca to 41b5c52 Compare October 28, 2026 12:06
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

Please rebase on 3cee68f

@javache done!

@meta-codesync
meta-codesync Bot merged commit f7de203 into react:main Oct 29, 2026
96 of 105 checks passed
@react-native-bot

Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @hryhoriiK97 in f7de203

When will my fix make it into a release? | How to file a pick request?

@react-native-bot react-native-bot added the Merged This PR has been merged. label Oct 29, 2026
meta-codesync Bot pushed a commit that referenced this pull request Oct 30, 2026
… signature, by name

## Summary:

This is a follow-up to #59414, suggested by @javache in [its review](#59414 (comment)): keep a method's `jmethodID` in the same per-module cache as its parsed JNI signature, and index that cache by method name, instead of having two caches.

Before this change, a Java TurboModule method's ID was cached by its caller: every generated host function kept a `static jmethodID cachedMethodId` and passed it to `invokeJavaMethod` by reference, and `JavaInteropTurboModule` kept a `methodIDs_` vector for the same purpose. `JavaTurboModule` then used that ID as the key of its `MethodInfo` cache (#59414).

With this change:

- `JavaTurboModule::MethodInfo` also holds the method's `jmethodID`, and `methodInfos_` is keyed by the method name (a `std::string`, looked up through a `std::string_view` without allocating). On a method's first call, `getMethodInfo` looks the method up with `GetMethodID` and parses its signature. A signature that doesn't match still turns into a JS error (redbox) before anything is called, and nothing is cached for it.
- `invokeJavaMethod` gets an overload that takes the method name and JNI signature as C strings and no `cachedMethodID`. Generated code passes string literals, so a call no longer builds two `std::string`s; the JNI signature is usually longer than the small-string buffer, so that was a heap allocation on every call.
- The previous overload (`const std::string&` name and signature, `jmethodID&`) is kept, for code generated by earlier codegen versions and for modules that call it directly, and marked `[[deprecated]]`. It forwards to the new one; `cachedMethodID` is no longer read or written. Libraries that ship pre-generated JNI code build it with `-Wall -Werror` (the codegen CMake template applies `target_compile_reactnative_options`), so their builds will fail on the deprecation warning until they regenerate that code.
- The codegen template for Java module JNI files (`GenerateModuleJniCpp`) no longer emits `static jmethodID cachedMethodId`, and `JavaInteropTurboModule` drops `methodIDs_`.

Besides one cache instead of two, every generated host function gets smaller: RNTester's `libreactnative.so` (which includes `FBReactNativeSpec`) is 61 KB smaller and its `libappmodules.so` 9.4 KB smaller.

## Changelog:

[ANDROID] [CHANGED] - Java TurboModules cache each method's ID with its parsed signature, by name, instead of in every generated host function

[ANDROID] [DEPRECATED] - `JavaTurboModule::invokeJavaMethod` with a `cachedMethodID` argument; use the overload without it

## Test Plan:

- `yarn jest packages/react-native-codegen` passes (64 suites, 3,130 tests); the `GenerateModuleJniCpp` snapshots are updated for the template change.
- `node scripts/clang-format.js --check` reports no changes for the touched C++ files.
- C++ API snapshots: the new `invokeJavaMethod` overload is added to the four `ReactAndroid*Cxx.api` snapshots by hand, where the generator sorts it; `scripts/cxx-api` can't run on current `main` locally (its codegen step fails with `SyntaxError: Unexpected token '{'` while processing `FBReactNativeSpec`, without this change too).
- `./gradlew :packages:rn-tester:android:app:assembleRelease -PreactNativeArchitectures=arm64-v8a` succeeds with this change; RNTester's generated `FBReactNativeSpec` and app module code use the new overload.
- **On a device.** Two RNTester release APKs built from the same local checkout (`main` at f7de203 with local benchmark screens), without and with this change, installed alternately on a moto g57 power (Android 16), three installs each with two launches. A Playground screen times synchronous calls to `NativeSampleTurboModule` (20,000 calls each, 5,000 for void, callback and promise calls), median of the six launches:

| Call (JNI signature) | before | this PR | change |
|---|--:|--:|--:|
| `getBool` (`(Z)Z`) | 1.10 µs | 1.15 µs | +4% |
| `getNumber` (`(D)D`) | 1.46 µs | 1.53 µs | +5%, ranges overlap |
| `getString` (`(Ljava/lang/String;)Ljava/lang/String;`) | 2.05 µs | 1.92 µs | −6% |
| `getArray`, 3 items | 10.34 µs | 10.06 µs | −3% |
| `getObject`, 3 keys | 9.90 µs | 9.58 µs | −3% |
| `getObject`, 20 keys | 35.67 µs | 35.28 µs | −1% |
| `getValue(number, string, object)` | 13.94 µs | 14.21 µs | +2%, within noise |
| `voidFunc` | 4.55 µs | 4.59 µs | within noise |
| `getValueWithCallback` | 8.54 µs | 7.99 µs | −6.5% |
| `getValueWithPromise` | 18.26 µs | 17.38 µs | −5% |

  Calls whose JNI signature is longer than the small-string buffer no longer allocate it, and are 5–6% cheaper. The cheapest calls, whose short signatures never allocated, now hash the method name instead of a pointer, about 50 ns more. Native library sizes are from the same two APKs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Differential Revision: D128349302

Pull Request resolved: #59619
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Merged This PR has been merged. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants